LOADING...

加载过慢请开启缓存(浏览器默认开启)

loading

游戏编程模式⑤(更新方法,子类沙盒)

2021/8/23

本文作为《Game Programming Pattern》(游戏编程原理)的摘要总结,记录为期一个月的阅读感想与心得体会。

撰写本文时,希望可以尽量遵循star法则,此后的文章同样以这四个要点作为大纲。

situation(情境)

task(任务)

action(行为)

result(结果)

本文代码尽量以unity c#进行演示(最近看的都是cpp代码,时常有被带偏的情况,本人实在太菜菜子了)


更新方法

通过对所有对象实例同时进行帧更新来模拟一系列相互独立的游戏对象

这个模式为游戏中的每个实体封装自身的行为,使游戏循环保持整洁并便于往循环中增加活移除实体。

situation

需要类似外星人,幽灵,展示这样的游戏实体逐帧的更新时,使用此模式可以让它们与玩家进行良好的交互。若是西洋棋这类不需要同时模拟所有对象并且逐步更新的对象时,此模式就不适用了。

ps:虽然棋类的对象的行为不一定逐帧更新,但动画可能依然是逐帧更新的,依然可以套用此模式。


task

定义一个update的抽象层,然后让游戏循环维护对象集合,在每一帧中遍历对象集合并调用他们的update(),从而进行游戏行为的更新。

这样就能达到将游戏对象的行为从游戏循环和其他行为那里分离出来的目的。


action

我们先从最简单的实体类开始:

class Entity
{
    public:
    Entity()
    :x(0),y(0){};
    
    virtual void update()=0;
    get()...
    set()...
    
    private:
    double x,y;
    
}

游戏维护的就是一系列这样的实体,我们将他们置入一个代表游戏世界的类:

class world
{
    public:
    world()
    :numEntity(0){}
    
    void GameLoop()
    {
        while(true)
        {
            //处理用户输入
            for(int i=0;i<numEntity;i++)
            {
                entities_[i].update();
            }
            //处理物理和渲染
        }
    }
    
    private:
     Entity* entities_[MAX_ENTITIES];
    int numEntity;
    
}

我们来编写具体的实体,比如骷髅守卫和魔法雕像。

首先是骷髅守卫:

class Skeleton : public Entity
{
    public:
    Skeleton()
        :patrollingLeft(false){}
    virtual void update()
    {
        if(patrollingLeft)
        {
            setX(getX()-1);
            if(getX()==0)patrollingLeft=false;
        }
        else
        {
            setX(getX()+1);
            if(getX()==100)patrollingLeft=true;
        }
    }
    
    private:
    bool patrollingLeft;
}

我们储存了一个patrollingLeft,这是为了确保在每一帧结束时我们都能储存游戏状态,从而得知下一帧要如何运行。patrollingLeft作为一个类成员变量能保证在update()的调用期间有效。

让我们来看看另一个实体:

class Statue : public Entity
{
    public:
    Statue(int delay_)
        :delay(delay_),frame(0){}
    virtual void update()
    {
        frame++;
        if(frame==delay)
        {
            shootLighting();
            frame=0;
        }
    }
    
    private:
    int frame;
    int delay;
    
}

这个类记录一个单独的帧计时器,时间到了就发射闪电并重置计时器

我们不难看出,这样编写是的向游戏世界添加实体变得很容易。因为每个实体都携带着自己所必须的东西,自给自足。

由于平时游玩的游戏都采用定时更新,变时渲染的游戏循环,所以更新方法暂时不往下延伸。


result

这种模式和游戏循环和组件模式共同构成了多数游戏引擎的核心部分。

我们不妨来讨论一些细节上的东西:

所有的游戏对象都在进行模拟,但并非是真正的同步

本模式中,游戏循环逐帧遍历对象集合并逐个更新。假如A对象在对象列表位于B对象的前面,当A更新时,将会看到B前一帧的状态;而当B对象更新时,将会看到A这一帧的状态,因为A在之前已经被更新了。

顺序更新是一件好事,因为真正平行的更新所有对象会带来语义死角,就好比两个npc同时往一个坐标移动,这该怎么解决?增量式的更新游戏世界,从一个有效的状态到下一个,不会产生对象状态的歧义。

若希望对象会比有序性,可以采用双缓冲模式,这样AB的更新顺序就不再重要了,因为他们获取的都是上一帧的状态。

更新期间修改对象必须谨慎进行

若一个敌人死亡掉落了一个物品,你可以将这个物品对象直接添加到对象集合的尾部,但这意味着这个对象有机会在产生的那一帧进行更新,而本帧玩家并未看到此物品。假如不希望发生此类情况,则可以在遍历之前储存当前对象列表的长度,然后只更新这么多的对象。

若在更新时移除对象,且移除的对象刚巧在所更新的对象之前,则会不小心跳过一个对象。例如更新到enemy[1]时,此时enemy[0]被移除,然后enemy[1]和enemy[2]上移(enemy[2]变成了enemy[1]),接下来更新enemy[2]时,原先的enemy[2]就被跳过了。

一个方法是将移除操作推迟到本次更新完成后,将对象标记为“已死亡”,但不从列表移除他,更新时跳过这些带有死亡标记的对象。但这些尸体依然占用着我们的储存空间。

另一个方法是改为从尾部遍历,这样移除对象的上移操作就不会产生影响。

update方法应该存在于哪里

  1. 若存于实体类中,是最简单的,不需要增加什么额外的类,若不需要很多种类的实体,那么这种方法可行,但实际项目很少这么做。因为每当希望实体有新的表现时,就需要创建子类,这样会积累大量的类。
  2. 组件模式中同样也有让实体/组件同步更新的功能,组件模式使得每个实体/组件在游戏世界中能独立于其他的实体/组件,渲染,AI,物理都只需要关注自己。这是种不错的方案。
  3. 也可以尝试将一个类的行为代理给其他的类。状态模式可以让你通过改变一个对象的代理来改变其行为。对象类型模式可以在多个相同类型的实体之间共享行为。此时仍然可以在实体类中保留update,只需要简单指向代理类对象的update就可以。这么做可以在代理类之外定义新的行为方式,就像使用组件模式一样灵活。

子类沙盒

使用基类提供的操作几何来定义子类的行为。

先创建一个基类,里面包含了所有的方法,它就是我们的沙盒。通过基类提供的保护函数来完成子类所复写的沙盒函数的函数体。这样催生了扁平的类层次结构,使得继承链不会太深。


situation

假如我们有一个带有大量子类的基类,基类能够提供所有子类可能需要执行的操作集合;

且子类之间有重复的代码,希望能简便的共享代码;最后希望继承类与程序的其他代码的耦合能最小化,那么不妨试试看子类沙盒模式吧(或者你已经在使用了)


task

我们希望减少冗余重复的代码,同时降低整个程序的耦合度。


action

先从我们的基类开始,它是我们的沙盒:

class SuperPower
{
    public:
    virtual ~SuperPower(){}
    
    protected:
    virtual void activate()=0;
    
    void move(){...}//可能调用物理代码
    void playSound(SoundID sound){...}//可能与引擎通信
    void spawnPaticles(PaticleType type,int count){}//可能调用粒子系统
}

activate()是纯虚函数 ,所以子类必须重写他。这能让子类实现者明确自己该做什么。

沙盒类中的其他方法调用了引擎的其他系统,不可避免的造成了耦合,但好在耦合被限制在了基类里面。

我们来创建自己需要的实体吧:

class SkyLaunch : public Superpower
{
    protected:

    virtual void activate()
    {
            if(GetZ()==0)
            {
                move(0,0,20);
                playSound(SOUND_SPROING);
                spawnPaticles(PATICLE_DUST,100);
            }

    }

}

我们可以在子类中搭建任何想要的行为,沙盒和子类的关系就好比技能和出招表。


result

基类提供越多的操作,子类与外部系统的耦合就越低,而基类与外部的耦合就越高,因为我们把耦合聚集到了基类自身。

使用这个模式下来,我们的基类可能塞满了方法。我们可以把一些方法转移到其他类来缓解这种情况。例如:

class Sound
{
    void play((SoundID sound){...}
    void stop(SoundID sound){...}
    void setVolume((SoundID sound,int volume)
}
class Superpower
{
    protected:
    Sound GetSound()
    {
        return sound;
    }
    private:
        Sound sound
}

这样就成功将功能转移到了耦合更低的第二候选类,减少了基类的函数数量,同时代码也更易于维护。